05 - MCP 网关:一个客户端,多个后端
前三篇讲的都是"业务 → 模型"这段流量。这一篇讲的是另一段:"Agent → 工具"。
这段流量在 2026 年基本被 MCP 统一了,而它带来的问题和 LLM 流量完全不同:
- LLM 请求是无状态的,MCP 连接是有状态的(有 session、有订阅、有服务端主动推送)
- LLM 后端是同质的(都能回答同一个问题),MCP 后端是异质的(每个提供不同的工具)
- LLM 网关做的是二选一,MCP 网关做的是聚合
所以 MCP 网关不是"给 MCP 做个反向代理",它更像一个协议感知的聚合器。
前置:01 - 网关是什么 里的 MCP 概念(让 AI 应用连接外部工具的协议,提供工具的一方叫 MCP Server)。
本篇回答:Agent 要连 5 个 MCP Server,网关怎么把它们伪装成一个?
会用到的词:
- JSON-RPC:MCP 使用的消息格式,每条消息有
method(方法名)和id(用来把响应和请求配对) tools/list/tools/call:MCP 里最核心的两个方法 —— 列出有哪些工具、调用某个工具- session(会话):MCP 连接是有状态的,客户端和服务端靠一个 session ID 维持上下文,这是它和普通 HTTP 请求最大的区别
- SSE(Server-Sent Events):服务端向客户端单向推送消息的长连接,MCP 用它来做服务端主动通知
一、核心问题:多个后端呈现为一个
客户端发一次 tools/list,期望拿回一份完整列表。但这份列表实际来自三个后端,而且:
- 三个后端各有各的 session ID
- 三个后端可能有同名工具
- 任何一个后端推送
tools/list_changed,都要转发给客户端 - 客户端调用某个工具时,要知道该发给谁
Envoy AI Gateway 的 internal/mcpproxy/(handlers.go 1,904 行 + session.go 801 行)就是在解决这四件事。
图片来自 envoyproxy/ai-gateway 官方提案 docs/proposals/006-mcp-gateway/arch.svg(Apache-2.0)
这张官方架构图把上面四件事的落点画得很清楚:用户请求进 Gateway Pod 的 Envoy 进程,MCP listener 把流量交给跑在 sidecar 里的 MCP Proxy(通过 UDS,也就是 Unix Domain Socket,同机通信不走网络),由它去连 MCP1 / MCP2 / MCP3 三个上游。注意图里标注的"Policies affect this part"—— 策略作用在 MCP Routes 这一层,也就是说授权规则能精确到某个 MCP 后端。
二、工具命名:用 __ 做后端命名空间
最朴素也最关键的一步 —— 给工具名加后端前缀:
// internal/mcpproxy/handlers.go
const nameSeparator = "__"
func downstreamResourceName(name string, backendName string) string {
return fmt.Sprintf("%s%s%s", backendName, nameSeparator, name)
}
所以后端 github 上的 search_issues 工具,客户端看到的是 github__search_issues。调用时反向解析:
func (m *mcpRequestContext) handleToolCallRequest(...) (handlerResult, error) {
backendName, toolName, err := upstreamResourceName(p.Name)
if err != nil {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name %s: %v", p.Name, err))
return handlerResult{}, err
}
backend, err := m.getBackendForRoute(s.route, backendName)
// ...
p.Name = toolName // 发给后端时把前缀去掉
实际效果长这样 —— 客户端一次 tools/list 拿到的是两个后端合并后的列表:

图片来自 agentgateway/agentgateway 官方示例 examples/mcp-multiplex/img/list.png(Apache-2.0)
time_get_current_time、time_convert_time 来自名为 time 的后端,everything_echo、everything_add 来自名为 everything 的后端 —— 前缀就是后端名。客户端完全不知道背后有两个 MCP Server,它只看到一份四个工具的列表。
(顺带一提:agentgateway 用单下划线 _ 做分隔符,Envoy AI Gateway 用双下划线 __。没有统一标准,这也意味着换网关时工具名会变,Agent 的提示词里如果硬编码了工具名就会失效。)
这个方案朴素但有代价:工具名会变长,而工具名和描述是要塞进模型上下文的。三十个工具、每个前缀多十几个字符,就是几百个 token 的固定开销。同时它也解释了为什么 MCP 生态里工具名普遍不能带 __。
同样的前缀技巧还用在了请求 ID 上,因为服务端可以反向给客户端发请求(比如采样),响应回来时得知道是哪个后端问的:
prefixedID = fmt.Sprintf("%d%si%s%s", v, nameSeparator, nameSeparator, backend)
后面那个 i / f / s 是原始 ID 的类型标记(int / float / string)—— JSON-RPC 的 ID 可以是数字也可以是字符串,加了前缀后必须能还原回原来的类型。这种细节是"给一个有状态协议做代理"和"给 HTTP 做代理"的真实难度差距。
三、会话映射:一对客户端,N 对后端
MCP 连接是有状态的,网关需要维护一层会话映射:
客户端断开时,网关需要清理全部后端会话;某个后端断开时,网关要决定是重连、还是把该后端的工具从列表中摘除 —— 后者会让客户端看到工具集在运行中发生变化。
type session struct {
route string
perBackendSessions map[filterapi.MCPBackendName]*compositeSessionEntry
// extraHeaders contains header values extracted from the current HTTP request to be forwarded to ALL backends.
// These are derived from the route's configured forward headers (e.g., OAuth claimToHeaders) and the current request's headers.
extraHeaders ...
// perBackendExtraHeaders contains per-backend header values extracted from the current HTTP request.
perBackendExtraHeaders ...
}
一个客户端 session 内部维护一张 后端 → 后端 session 的映射。请求头的转发分两层:一层广播给所有后端,一层按后端定制(不同后端的鉴权凭证显然不能混用)。
session ID 走标准头:
const sessionIDHeader = "mcp-session-id"
生命周期结束时逐个通知后端:
func (s *session) Close() error {
for backendName, sess := range s.perBackendSessions {
sessionID := sess.sessionID
if sessionID == "" {
// Stateless backend, nothing to do.
continue
}
req, err := http.NewRequest(http.MethodDelete, s.reqCtx.backendListenerAddr, nil)
// ...
req.Header.Set(sessionIDHeader, sessionID.String())
// ...
// Some stateless backends may return 404 Not Found if they don't track sessions.
}
}
两处注释暴露了真实世界的混乱:有些后端是无状态的,根本不发 session ID;有些后端收到 DELETE 会回 404。 网关必须容忍这两种情况,不能因为后端不守规矩就报错。
initialize 是唯一不需要 session 的方法:
// We do require a Session ID. If it is not present for requests other than initialize,
if s == nil && msg.Method != "initialize" {
四、通知流的合并
MCP 允许服务端主动推送。客户端只开一条 GET 流,网关得把 N 个后端的流合并进去:
// streamNotifications streams notifications from all backends in this session to the given writer.
func (s *session) streamNotifications(ctx context.Context, w http.ResponseWriter, toolChangeSignaler changeSignaler) error {
backendMsgs := s.sendToAllBackends(ctx, http.MethodGet, nil, nil, nil)
for {
select {
// events received from the upstream MCP backends
case event, ok := <-backendMsgs:
if !ok {
// All backend notification streams have ended (e.g. backends returned 405
同时网关自己也会往这条流里塞两种消息:
func newHeartBeatPingMessage() *jsonrpc.Request
func newToolListChangedMessage() *jsonrpc.Request
心跳是为了保活(SSE 长连接经过中间设备容易被静默掐断),tools/list_changed 是因为后端集合本身可能变化 —— 某个后端下线了,客户端手里的工具列表就该更新。
断线重连靠 lastEventID,而这个 ID 是加密的:
encrypted, err := s.reqCtx.sessionCrypto.Encrypt(lastEventID)
因为它是 N 个后端 event ID 的拼接,不加密就等于把内部拓扑(有几个后端、叫什么)泄露给客户端。
五、工具级授权
Envoy AI Gateway 在 tools/call 上做两层检查。第一层是路由级白名单:
selector := route.toolSelectors[backendName]
if selector != nil && !selector.allows(toolName) {
onErrorResponse(w, http.StatusBadRequest, fmt.Sprintf("invalid tool name: %s", toolName))
return result, fmt.Errorf("%w: %s", errInvalidToolName, toolName)
}
第二层是带 scope 的授权:
allowed, requiredScopes := m.authorizeRequest(route.authorization, &authorizationRequest{
Headers: r.Header,
HTTPMethod: r.Method,
Host: r.Host,
HTTPPath: httpPath,
MCPMethod: req.Method,
Backend: backendName,
Tool: toolName,
Params: p,
})
if !allowed {
// Specify the minimum required scopes in the WWW-Authenticate header.
// Reference: .../specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
注意授权请求里带了 Params —— 也就是说策略可以基于工具参数做判断,而不只是工具名。"允许调用 delete_file,但只在 /tmp 下"这种规则是能写出来的。这是 Agent 场景相比传统 API 网关最本质的新需求:同一个工具,参数不同,风险差几个数量级。
被拒时通过 WWW-Authenticate 头告知所需 scope,这直接对应 MCP 规范里的 runtime insufficient-scope 错误处理。
六、agentgateway 的 CEL 表达式方案
agentgateway 是 Rust 实现,crates/agentgateway/src/mcp/(handler.rs 72 KB、auth.rs 38 KB、session.rs 36 KB、streamablehttp.rs 19 KB)解决的是同一批问题,但策略表达方式完全不同 —— 它用 CEL(Common Expression Language),而且专门 fork 了一份 CEL 实现放在 crates/cel-fork/。
mcp/auth.rs 里能看到几个很具体的工程决策:
"MCP auth configured; validating Authorization header (mode={:?})",
let issuer = auth.issuer.trim_end_matches('/');
issuer 末尾斜杠要裁掉 —— OAuth issuer 的尾斜杠不一致是经典踩坑点,配置里写 https://example.com/ 而 token 里是 https://example.com,校验就失败。
if matches!(auth.provider, Some(McpIDP::Entra {}))
Microsoft Entra 需要单独 case。 文件里出现了两处针对 Entra 的分支,说明它在某些行为上偏离了通用 OAuth 流程。企业落地绕不开这个。
matches!(grant_type, Some("authorization_code" | "refresh_token"))
只接受这两种 grant type,其他一律拒绝。
llm/policy/mod.rs(2,156 行)里还有一个很实用的设计 —— 模型别名的通配符匹配:
pub struct ModelAliasPattern {
#[serde(with = "serde_regex")]
regex: regex::Regex,
// Stores the compiled regex and original pattern length for specificity sorting.
}
impl ModelAliasPattern {
pub fn from_wildcard(pattern: &str) -> Result<Self, String> {
// Convert wildcard to regex: escape all chars, then replace \* with (.*)
let escaped = regex::escape(pattern);
let regex_pattern = escaped.replace(r"\*", "(.*)");
// ...
}
pub fn specificity(&self) -> usize { ... }
}
通配符转正则,按原始模式长度排序决定优先级 —— gpt-4* 和 gpt-4o* 同时匹配时,更长的(更具体的)那个赢。这是路由规则里"最长前缀匹配"思想的直接搬用。
6.1 第三种实现:Higress

图片来自 higress-group/higress 官方 README(Apache-2.0)
同一件事,Higress 的官方架构图画得最直白:左边是各种 MCP 客户端(Claude、Cursor、Cline、自研 Agent),进网关后走一条固定的流水线 —— MCP Sessions → OAuth2 Auth → Audit Logs → Rate Limits —— 再分发到内置的 MCP Server,最后落到真正的后端(内部 API、三方 API、数据库)。
把这张图和本 篇前面拆的代码对起来看:MCP Sessions 就是第三节的 perBackendSessions,OAuth2 Auth 是第五节的授权,Rate Limits 是 04 篇的限流。三家画法不同,但流水线上的环节是同一套。
七、实现对比
| Envoy AI Gateway | agentgateway | |
|---|---|---|
| 语言 | Go | Rust |
| MCP 代码量 | mcpproxy/ 约 130 KB | mcp/ 约 165 KB |
| 策略表达 | K8s CRD + Go 代码 | CEL 表达式(自带 fork) |
| 工具命名空间 | backend__tool | 同类机制 |
| 授权粒度 | 后端 / 工具 / 参数 | 后端 / 工具 / 参数(CEL) |
| 配置方式 | MCPRoute CRD | 配置文件 / xDS |
| 前置依赖 | Kubernetes + Envoy | 无 |
选型判断很直接:已经在 K8s 上跑 Envoy 就用前者,否则用后者。能力上两家已经相当接近,差别主要在"策略写在 YAML 里还是写成表达式"这个偏好上。
八、演进方向
三个已经能看到苗头的方向:
-
工具列表本身要被裁剪。 现在网关是把 N 个后端的工具全量聚合返回。等后端数量上去,
tools/list的结果会大到塞不进上下文。裁剪逻 辑放在网关是最合适的 —— 它是唯一同时看得见"有哪些工具"和"用户是谁"的地方。 -
工具调用要被计费。 现在配额只算 LLM token(见 04 - 多租户与配额),但一次昂贵的工具调用可能比一次模型调用贵得多。
-
工具是攻击面。
tools/list返回的描述文本会直接进模型上下文 —— 一个恶意 MCP server 可以在工具描述里藏提示注入。网关看得见所有工具描述,是做检测的天然位置。这条线属于下一个专题。
下一篇 → 06 - 缓存:同一个问题问两遍,第二遍能不能不花钱?"差不多的问题"算不算同一个问题?
← 回到 专题索引 · Agent Infra 板块总览